Skip to content

Level 0 merge #4697

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open

Level 0 merge #4697

wants to merge 6 commits into from

Conversation

SirTyson
Copy link
Contributor

Description

Resolves #4696

This change special cases level 0 Bucket merges to happen all in-memory, without file IO. To accomplish this, all level 0 buckets retain an in-memory vector of their contents. Instead of using file iterators to perform merges, these in-memory vectors are used. I've also reduced the number of times we index buckets during addBatch and use the in-memory vectors for indexing as well.

This reduces addBatch time by about 50% under high load as seen in max tps tests.

Checklist

  • Reviewed the contributing document
  • Rebased on top of master (no merge commits)
  • Ran clang-format v8.0.0 (via make format or the Visual Studio extension)
  • Compiles
  • Ran all tests
  • If change impacts performance, include supporting evidence per the performance document

Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR specializes level 0 Bucket merges to operate entirely in-memory, reducing addBatch time under high load. Key changes include:

  • Updating upgrade tests to reflect new merge counter expectations.
  • Adding in-memory merge support in LiveBucket (including new state mEntries and associated APIs) and updating related merging logic.
  • Adjusting tests (BucketManagerTests, BucketIndexTests) and index construction (InMemoryIndex) to account for in-memory entries.

Reviewed Changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/herder/test/UpgradesTests.cpp Updates in upgrade test assertions to reflect revised merge counter values.
src/bucket/test/BucketManagerTests.cpp Adds conditional index file existence checks based on configured page size.
src/bucket/test/BucketIndexTests.cpp Modifies cache hit/miss expectations to subtract in-memory entry counts.
src/bucket/LiveBucket{.h,.cpp} Introduces new in-memory merging support and related APIs (hasInMemoryEntries etc.).
src/bucket/InMemoryIndex{.h,.cpp} Extends in-memory index construction to support new merge behaviors.
src/bucket/BucketOutputIterator{.h,.cpp} Updates getBucket to optionally use in-memory index state during bucket adoption.
src/bucket/BucketListBase{.h,.cpp} Introduces prepareFirstLevel for level 0 in-memory merges with fallback logic.
src/bucket/BucketBase{.h,.cpp} Refactors mergeInternal and related merging logic to support new in-memory flows.
src/bucket/BucketMergeAdapter.h and HotArchiveBucket{.h,.cpp} Minor updates to support passing putFunc instead of output iterators.
Comments suppressed due to low confidence (2)

src/bucket/HotArchiveBucket.h:67

  • Typo in comment: 'te' should be 'the'.
    // This function always writes te given entry to the output

src/bucket/test/BucketIndexTests.cpp:312

  • Ensure that subtracting 'sumOfInMemoryEntries' from 'numLoads' accurately accounts for in-memory entries in cache hit/miss metrics, and that tests remain robust across different environments.
                REQUIRE(hitMeter.count() == startingHitCount + numLoads - sumOfInMemoryEntries);

Copy link
Contributor

@marta-lokhova marta-lokhova left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, I'm a fan of this change! I added a few suggestions for safer code plus a couple of clarification questions/requests for comments. Could you also share a bit more about performance gains you've observed while running this? I imagine fully in-memory addBatch flow would reduce most of addBatch overhead.

@@ -2103,13 +2103,12 @@ TEST_CASE("upgrade to version 11", "[upgrades]")
// Check several subtle characteristics of the post-upgrade
// environment:
// - Old-protocol merges stop happening (there should have
// been 6 before the upgrade, but we re-use a merge we did
// at ledger 1 for ledger 2 spill, so the counter is at 5)
// been 6 before the upgrade)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this test changed. We should still be able to re-use merges, shouldn't we?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we do the quick, in-memory merge, we don't use FutureBucket so we can't "re-use" it as the original comment suggests. This is fine though, as re-using a level 0 merge can only occur when level 1 is empty. This will happen in tests since we start at genesis, but won't happen in prod since we have TX volume.

@@ -234,7 +234,15 @@ TEST_CASE_VERSIONS("bucketmanager ownership", "[bucket][bucketmanager]")
std::string indexFilename =
app->getBucketManager().bucketIndexFilename(b->getHash());
CHECK(fs::exists(filename));
CHECK(fs::exists(indexFilename));

if (b->getIndexForTesting().getPageSize() == 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, why did this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was configured to not use any in-memory indexes via the BUCKETLIST_DB_INDEX_CUTOFF config. With this change, we unconditionally store level 0 in memory regardless of the value of this flag. In memory indexes don't get serialized, so we have to add the check here. This is fine, since the memory overhead of level 0 is trivial, so there's no reason to support having no buckets in memory.

std::vector<BucketInputIterator<BucketT>> const& shadowIterators)
{
// Don't count shadow metrics for Hot Archive BucketList
if constexpr (std::is_same_v<BucketT, HotArchiveBucket>)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be calling this function on HotArchiveBuckets at all, right? (shadows were dropped in protocol 12). Could we enforce that? I realize this was here before, but since we're already making changes, this is a good opportunity to harden the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to factor out shadows as much as possible from HotArchive, preferring type dispatch over branching on types when possible. Unfortunately, shadows are embedded pretty heavily in many parts of the Bucket subsystem, but I was able to remove shadows from all HotArchive specific function headers, and removed some of them from BucketBase methods.

@@ -36,10 +36,87 @@ InMemoryBucketState::scan(IterT start, LedgerKey const& searchKey) const
return {IndexReturnT(), mEntries.begin()};
}

InMemoryIndex::InMemoryIndex(BucketManager& bm,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there's a lot of code duplication with the other InMemoryIndex constructor, would it be possible to refactor this? (Maybe MergeAdapter is really just an iterator rather than a container for two bucket inputs?)

constexpr std::streamoff xdrOverheadBetweenEntries = 4;

// 4 bytes of BucketEntry overhead for METAENTRY
constexpr std::streamoff xdrOverheadForMetaEntry = 4;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much do we win from in-memory indexing at commit time? The manual offset managing worries me a bit, as it's error-prone. We now need to maintain two different ways of managing offsets.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Difficult to say, as it's very setup dependent. On our validators, you'll probably see little change since the new file will be in the OS page cache given that stellar-core is the only thing running on the node and we have lots of excess RAM. On captive-core, it's a different story. You don't have much extra RAM for caches given RPC/Horizon/PSQL will dominate RAM usage. You're also less likely to be in the page cache given disk read contention with other processes, and the penalty for a file IO cache miss can be very high if you're on EBS. Personally I think since we have everything in user space memory, it's much better to index based on memory instead of file IO in the blocking path. I'll see if I can clean this up with some constexpr that are more dynamic based on BucketEntry XDR directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it's actually difficult to get these values in any sort of constexpr sort of way. That being said, I think the hardcoded values are worthwhile. The performance savings can be very significant avoiding file IO altogether. Also, I think the risk is relatively minimal, as these constexpr values can't ever be changed. xdrOverheadBetweenEntries is intrinsic to the XDR spec, and xdrOverheadForMetaEntry is the size of BucketEntryType stored by the BucketEntry, which also can't be changed. Even if we changed BucketEntry, the cases within the switch statement might change size, but the overhead of the switch itself remains constant. We dynamically measure the size of the actual BucketEntry data via xdr::xdr_size(metadata), so I think these constants are fine.

{
// If we have an in-memory merged result, use that
setCurr(mNextCurrInMemory);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we add a level 0 assert here as well?

@@ -363,6 +363,7 @@ template <class BucketT> class BucketLevel
FutureBucket<BucketT> mNextCurr;
std::shared_ptr<BucketT> mCurr;
std::shared_ptr<BucketT> mSnap;
std::shared_ptr<BucketT> mNextCurrInMemory;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use std::variant to represent "next curr" in a single variable? (it'd be safer and less error-prone)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to implement this via with std::monostate too, but unfortunately we use a single long-lasting FutureBucket object per level and reset it manually. Many callers assume this object exists, and it was too big of a refactor to implement initializing the FutureBucket correctly wrt monostate.

auto snap = LiveBucket::fresh(
app.getBucketManager(), currLedgerProtocol, inputVectors...,
countMergeEvents, app.getClock().getIOContext(), doFsync,
/*storeInMemory=*/true, /*shouldIndex=*/false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldIndex is false here because indexing is redundant: we merge this newly created bucket right away and create a new (persistent) index anyway. Is that correct? I think a comment with some explanation would be nice here.

out.put(e);
}

// Store the merged entries in memory in the new bucket in case this is
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused about this comment: why do we merge level 1 in-memory?

Copy link
Contributor Author

@SirTyson SirTyson May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya this comment is wrong and I need to correct it. We need to maintain the in-memory vector in curr since the next ledger will need to execute an in-memory merge with the output of this function before it gets snapped. What I meant to say in this comment is that when the curr bucket gets snapped, we don't use the in-memory entries for level 0 snap, but maintain the vector anyway since it will be GC'd soon. I confused level 0 snap with level 1. It's a little confusing since when you write to the BucketList there's an implicit level -1 snap that merges with level 0 curr

@SirTyson SirTyson changed the base branch from release/v22.3.0 to master May 30, 2025 22:48
@SirTyson
Copy link
Contributor Author

Should have addressed all comments and rebased on master.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Reduce File IO for BucketList writes
2 participants